[AWS CDK] ALBとCognitoを使ってOktaをIdPとするSAML認証をしてみた
サクッとSAML認証を実装したい
こんにちは、のんピ(@non____97)です。
皆さんサクッとSAML認証を実装したいなと思ったことはありますか? 私はあります。
自分でSAML認証のService Provider(SP)側の処理を実装するのは大変です。そのような場合はALBとCognitoを使うと簡単に行えます。
ということで実際にやってみました。今回はIdPとしてOktaを使用します。
「SAML認証ってなんやねん」や「OktaのSAMLアプリってどうやって作成すればいいんだ」、「CognitoでSAML認証ってどうやって行えばいいんだ」という方は以下ドキュメントをご覧ください。
また、せっかくなので以下アップデートで可能になった署名付きSAMLリクエストと、暗号化されたSAMLレスポンスも行います。
AWS CDKのコードの説明
検証環境
作成する環境の構成図は以下のとおりです。
ALBでCognitoを使ったSAML認証をするようにします。
また、AWS CDKのコードは以下リポジトリに保存しています。
SAML認証
SAML認証周りでConstructを分けています。
やっていることは以下のとおりです。
- ユーザープールの作成
- SAML認証なのでセルフサインアップは無効
- 作成するカスタムドメインのゾーンのルートドメインのAレコードを作成
- 以下re:Postに記載のとおり、
- Amazon Cognito のカスタムドメインエラーをトラブルシューティングする | AWS re:Post
- ユーザープールドメインの作成
- 今回はCognitoドメインではなく、カスタムドメイン
- SAML IdPのメタデータ等が指定されている場合は以下の処理を実施
- SAMLアイデンティティプロバイダーの作成
- アプリケーションクライアントの作成
実際のコードは以下のとおりです。
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { SamlAuthProperty } from "../../parameter/index";
import { HostedZoneConstruct } from "./hosted-zone-construct";
import { CertificateConstruct } from "./certificate-construct";
export interface SamlAuthConstructProps extends SamlAuthProperty {
hostedZoneConstruct: HostedZoneConstruct;
certificateConstruct: CertificateConstruct;
}
export class SamlAuthConstruct extends Construct {
public readonly userPool: cdk.aws_cognito.IUserPool;
public readonly userPoolDomain: cdk.aws_cognito.IUserPoolDomain;
public readonly userPoolClient: cdk.aws_cognito.IUserPoolClient;
constructor(scope: Construct, id: string, props: SamlAuthConstructProps) {
super(scope, id);
// User pool
const userPool = new cdk.aws_cognito.UserPool(this, "Default", {
selfSignUpEnabled: false,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
this.userPool = userPool;
// Custom Domain
const rootDomainRecord = new cdk.aws_route53.ARecord(
this,
"RootDomainRecord",
{
zone: props.hostedZoneConstruct.hostedZone,
target: cdk.aws_route53.RecordTarget.fromIpAddresses("127.0.0.1"),
}
);
const userPoolDomain = userPool.addDomain("CustomDomain", {
customDomain: {
domainName: `${props.domainName}.${props.hostedZoneConstruct.hostedZone.zoneName}`,
certificate: props.certificateConstruct.certificate,
},
});
this.userPoolDomain = userPoolDomain;
userPoolDomain.node.defaultChild?.node.addDependency(rootDomainRecord);
new cdk.aws_route53.ARecord(this, "CustomDomainRecord", {
zone: props.hostedZoneConstruct.hostedZone,
recordName: props.domainName,
target: cdk.aws_route53.RecordTarget.fromAlias(
new cdk.aws_route53_targets.UserPoolDomainTarget(userPoolDomain)
),
});
if (!props.saml) {
return;
}
// User Pool Identity Provider
const userPoolProvider = new cdk.aws_cognito.UserPoolIdentityProviderSaml(
this,
"UserPoolProvider",
{
userPool,
metadata: cdk.aws_cognito.UserPoolIdentityProviderSamlMetadata.url(
props.saml.metadataURL
),
encryptedResponses: true,
requestSigningAlgorithm: cdk.aws_cognito.SigningAlgorithm.RSA_SHA256,
attributeMapping: {
email: cdk.aws_cognito.ProviderAttribute.AMAZON_EMAIL,
},
}
);
// User Pool Client
const userPoolClient = userPool.addClient("UserPoolClient", {
generateSecret: true,
oAuth: {
callbackUrls: props.saml.callbackUrls,
logoutUrls: props.saml.logoutUrls,
flows: {
implicitCodeGrant: false,
authorizationCodeGrant: true,
},
scopes: [
cdk.aws_cognito.OAuthScope.OPENID,
cdk.aws_cognito.OAuthScope.EMAIL,
cdk.aws_cognito.OAuthScope.PROFILE,
],
},
authFlows: {
userSrp: true,
},
preventUserExistenceErrors: true,
supportedIdentityProviders: [
cdk.aws_cognito.UserPoolClientIdentityProvider.custom(
userPoolProvider.providerName
),
],
});
this.userPoolClient = userPoolClient;
}
}
やってみた
Cognitoユーザープールの作成
実際にデプロイしてみます。
まず、Cognitoユーザープールを作成します。その他VPCやRoute 53 Public Hosted Zoneなど諸々必要なリソースも作成します。
AWS CDKのコードのパラメーターは以下のとおりです。
export const samlAppStackProperty: SamlAppStackProperty = {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
props: {
hostedZone: {
zoneName: "saml-app.non-97.net",
},
certificate: {
certificateDomainName: "*.saml-app.non-97.net",
},
samlAuth: {
domainName: "auth",
},
network: {
vpcCidr: "10.10.0.0/20",
subnetConfigurations: [
{
name: "public",
subnetType: cdk.aws_ec2.SubnetType.PUBLIC,
cidrMask: 27,
},
],
maxAzs: 2,
natGateways: 0,
},
asg: {
machineImage: cdk.aws_ec2.MachineImage.latestAmazonLinux2023({
cachedInContext: true,
}),
instanceType: new cdk.aws_ec2.InstanceType("t3.micro"),
subnetSelection: {
subnetType: cdk.aws_ec2.SubnetType.PUBLIC,
},
},
alb: {
internetFacing: true,
allowIpAddresses: ["0.0.0.0/0"],
subnetSelection: {
subnetType: cdk.aws_ec2.SubnetType.PUBLIC,
},
recordName: "www",
},
},
};
デプロイ後、Cognitoユーザープールを確認します。ユーザープールIDはIdP側で使用するので控えておきます。
Cognitoが使用するドメインも控えておきます。後ほど使用します。
Oktaの設定
次にIdPであるOktaの設定をします。
各パラメーターの詳細は以下Okta公式ドキュメントをご覧ください。
Oktaにログイン後、Applications
-Applications
-Create App Integrations
をクリックします。
SAML 2.0
を選択します。
アプリ名を入力します。
事前に確認した内容をもとにSAMLの設定をします。
- Single Sign On URL :
https://Cognitoドメイン or カスタムドメイン/saml2/idpresponse
- Audience Restriction :
urn:amazon:cognito:sp:CognitoユーザープールID
- Name ID format :
Persistent
- Attribute Statements : Oktaの
user.email
をemail
として渡すように設定
Name ID format
をPersistent
に設定しない場合は、ALBにアクセスをして認証をする際にNameIDPolicy 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' is not the configured Name ID Format 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified' for the app
と400エラーになります。
最後にアプリの種類を選択します。
設定が完了するとメタデータURLが発行されます。後ほど使用するので控えておきましょう。
今のままでは認証に使用するユーザーが紐づいていないので、ユーザーを割り当てます。
Assignments
タブからPeopleを確認します。
適当にユーザーを割り当てます。
Cognitoの設定変更
IdPで使用するメタデータURLを確認できたので、Cognito側でSAMLのアイデンティティプロバイダーを設定します。
AWS CDKのコードのパラメーターは以下のとおりです。
- metadataURL : Oktaで確認したメタデータURL
- callbackUrls :
https://ALBのFQDN/oauth2/idpresponse
export const samlAppStackProperty: SamlAppStackProperty = {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
props: {
.
.
(中略)
.
.
samlAuth: {
domainName: "auth",
saml: {
metadataURL:
"<Oktaで確認したメタデータURL>",
callbackUrls: ["https://www.saml-app.non-97.net/oauth2/idpresponse"],
},
},
.
.
(中略)
.
.
},
};
npx cdk diff
を叩いて差分を確認します。アイデンティティプロバイダーやアプリケーションクライアント、ALBの作成が行われそうですね。
$ npx cdk diff
[WARNING] aws-cdk-lib.aws_ec2.LaunchTemplateProps#keyName is deprecated.
- Use `keyPair` instead - https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2-readme.html#using-an-existing-ec2-key-pair
This API will be removed in the next major release.
Stack SamlAppStack
Hold on while we create a read-only change set to get a diff with accurate replacement information (use --no-change-set to use a less accurate but faster template-only diff)
Security Group Changes
┌───┬───────────────────────────────────────────────────────┬─────┬──────────┬───────────────────────────────────────────────────────┐
│ │ Group │ Dir │ Protocol │ Peer │
├───┼───────────────────────────────────────────────────────┼─────┼──────────┼───────────────────────────────────────────────────────┤
│ + │ ${AlbConstruct/Default/SecurityGroup.GroupId} │ In │ TCP 443 │ Everyone (IPv4) │
│ + │ ${AlbConstruct/Default/SecurityGroup.GroupId} │ Out │ TCP 443 │ Everyone (IPv4) │
│ + │ ${AlbConstruct/Default/SecurityGroup.GroupId} │ Out │ TCP 80 │ ${AsgConstruct/Default/InstanceSecurityGroup.GroupId} │
├───┼───────────────────────────────────────────────────────┼─────┼──────────┼───────────────────────────────────────────────────────┤
│ + │ ${AsgConstruct/Default/InstanceSecurityGroup.GroupId} │ In │ TCP 80 │ ${AlbConstruct/Default/SecurityGroup.GroupId} │
└───┴───────────────────────────────────────────────────────┴─────┴──────────┴───────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)
Resources
[+] AWS::Cognito::UserPoolClient SamlAuthConstruct/Default/UserPoolClient SamlAuthConstructUserPoolClient38E678A0
[+] AWS::Cognito::UserPoolIdentityProvider SamlAuthConstruct/UserPoolProvider SamlAuthConstructUserPoolProvider32C7AAF5
[+] AWS::EC2::SecurityGroupIngress AsgConstruct/Default/InstanceSecurityGroup/from SamlAppStackAlbConstructSecurityGroupF181F2FC:80 AsgConstructInstanceSecurityGroupfromSamlAppStackAlbConstructSecurityGroupF181F2FC80F2DEAFB2
[+] AWS::ElasticLoadBalancingV2::LoadBalancer AlbConstruct/Default AlbConstructF9C2F654
[+] AWS::EC2::SecurityGroup AlbConstruct/Default/SecurityGroup AlbConstructSecurityGroup6F1A15B1
[+] AWS::EC2::SecurityGroupEgress AlbConstruct/Default/SecurityGroup/to SamlAppStackAsgConstructInstanceSecurityGroup25C8618D:80 AlbConstructSecurityGrouptoSamlAppStackAsgConstructInstanceSecurityGroup25C8618D80B02521F2
[+] AWS::ElasticLoadBalancingV2::Listener AlbConstruct/Default/ListenerHttps AlbConstructListenerHttpsA10A42FE
[+] AWS::ElasticLoadBalancingV2::TargetGroup AlbConstruct/TargetGroup AlbConstructTargetGroup0AE1146B
[+] AWS::Route53::RecordSet AlbConstruct/AliasRecord AlbConstructAliasRecord09C3EC5D
[~] AWS::AutoScaling::AutoScalingGroup AsgConstruct/Default/ASG AsgConstructASG899212B0
└─ [+] TargetGroupARNs
└─ [{"Ref":"AlbConstructTargetGroup0AE1146B"}]
✨ Number of stacks with differences: 1
npx cdk deploy
でデプロイをします。
デプロイ完了後、ALBのリスナーを確認すると、作成したCognitoユーザープールやアプリケーションクライアントを使って認証をすることが分かります。
作成されたアプリケーションクライアントは以下のとおりです。
動作確認
動作確認をします。
Oktaにサインインしていない状態でhttps://www.saml-app.non-97.net/
にアクセスします。
すると、Oktaのサインイン画面に遷移しました。
認証情報を入力してサインインすると、Oktaのトップページが表示されました。
再度https://www.saml-app.non-97.net/
にアクセスします。
すると、401 Authorization Requiredとなってしまいました。
ALBのアクセスログを確認すると、以下のようなログが出力されていました。AuthInvalidStateParam
としてエラーになっていそうです。
h2 2024-04-23T01:22:12.765680Z app/SamlAp-AlbCo-FnNoQ8SmD7n9/fc6f03c0e4c7a9bb 110.66.137.110:62065 - -1 -1 -1 401 - 626 617 "GET https://www.saml-app.non-97.net:443/oauth2/idpresponse?error_description=Invalid+SAML+response+received%3A+Responses+must+contain+exactly+one+Encrypted+Assertion&error=server_error HTTP/2.0" "<ユーザーエージェント> " TLS_AES_128_GCM_SHA256 TLSv1.3 - "Root=1-66270d44-09196e8b3dc780c35f1d88f6" "www.saml-app.non-97.net" "session-reused" -1 2024-04-23T01:22:12.765000Z "authenticate" "-" "AuthMissingStateParam" "-" "-" "-" "-"
h2 2024-04-23T01:22:13.118116Z app/SamlAp-AlbCo-FnNoQ8SmD7n9/fc6f03c0e4c7a9bb 110.66.137.110:62065 - -1 -1 -1 302 - 75 628 "GET https://www.saml-app.non-97.net:443/ HTTP/2.0" "<ユーザーエージェント> " TLS_AES_128_GCM_SHA256 TLSv1.3 arn:aws:elasticloadbalancing:us-east-1:<AWSアカウントID> :targetgroup/SamlAp-AlbCo-YM7SRSNA1MO0/9d93a3233a61d833 "Root=1-66270d45-7e8f39e75b7cb4d341059d3e" "www.saml-app.non-97.net" "session-reused" 0 2024-04-23T01:22:13.117000Z "authenticate" "-" "-" "-" "-" "-" "-"
h2 2024-04-23T01:22:14.118030Z app/SamlAp-AlbCo-FnNoQ8SmD7n9/fc6f03c0e4c7a9bb 110.66.137.110:62065 - -1 -1 -1 302 - 44 631 "GET https://www.saml-app.non-97.net:443/ HTTP/2.0" "<ユーザーエージェント> " TLS_AES_128_GCM_SHA256 TLSv1.3 arn:aws:elasticloadbalancing:us-east-1:<AWSアカウントID> :targetgroup/SamlAp-AlbCo-YM7SRSNA1MO0/9d93a3233a61d833 "Root=1-66270d46-15de8dc3467061df4b6c3189" "www.saml-app.non-97.net" "session-reused" 0 2024-04-23T01:22:14.117000Z "authenticate" "-" "-" "-" "-" "-" "-"
h2 2024-04-23T01:22:16.672090Z app/SamlAp-AlbCo-FnNoQ8SmD7n9/fc6f03c0e4c7a9bb 110.66.137.110:62065 - -1 -1 -1 401 - 374 616 "GET https://www.saml-app.non-97.net:443/oauth2/idpresponse?error_description=Invalid+SAML+response+received%3A+Responses+must+contain+exactly+one+Encrypted+Assertion&state=t1vREOOgsJWr72GsFMdhdZ0b%2B3RIXdkeysQ9EOclCQIGA9lLhSfaQF3AKwMxgGl2tdVfKbiOxZnpS%5C%2FXEMakkAKyXGyl57LU6u0C4K5BKoXar6ZmgseCA4Uq%2BniThQEsDcq2gidiaQkf8BG9b2nsTPvsKaKrFAGaD5qNjSEZlVbU0nBOc5zWNGihoSKjjtgBqiFGDnGXvJqf4DQfEIvHh6UyJaHSWDy9YdP%2BMQIJRcXJqjGOG7hEKCg%3D%3D&error=server_error HTTP/2.0" "<ユーザーエージェント> " TLS_AES_128_GCM_SHA256 TLSv1.3 - "Root=1-66270d48-56daab6f511acd314eb31546" "www.saml-app.non-97.net" "session-reused" -1 2024-04-23T01:22:16.671000Z "authenticate" "-" "AuthInvalidStateParam" "-" "-" "-" "-"
h2 2024-04-23T01:24:17.128488Z app/SamlAp-AlbCo-FnNoQ8SmD7n9/fc6f03c0e4c7a9bb 110.66.137.110:61004 - -1 -1 -1 401 - 860 617 "GET https://www.saml-app.non-97.net:443/oauth2/idpresponse?error_description=Invalid+SAML+response+received%3A+Responses+must+contain+exactly+one+Encrypted+Assertion&state=t1vREOOgsJWr72GsFMdhdZ0b%2B3RIXdkeysQ9EOclCQIGA9lLhSfaQF3AKwMxgGl2tdVfKbiOxZnpS%5C%2FXEMakkAKyXGyl57LU6u0C4K5BKoXar6ZmgseCA4Uq%2BniThQEsDcq2gidiaQkf8BG9b2nsTPvsKaKrFAGaD5qNjSEZlVbU0nBOc5zWNGihoSKjjtgBqiFGDnGXvJqf4DQfEIvHh6UyJaHSWDy9YdP%2BMQIJRcXJqjGOG7hEKCg%3D%3D&error=server_error HTTP/2.0" "<ユーザーエージェント> " TLS_AES_128_GCM_SHA256 TLSv1.3 - "Root=1-66270dc1-7fead44f03e95b9b7c3c9104" "www.saml-app.non-97.net" "session-reused" -1 2024-04-23T01:24:17.128000Z "authenticate" "-" "AuthInvalidStateParam" "-" "-" "-" "-"
error_description
クエリの文字列を確認すると、Invalid SAML response received: Responses must contain exactly one Encrypted Assertion
と記載されています。
この原因はSAMLのアイデンティティプロバイダーでIdPから暗号化されたSAMLアサーションを要求するように設定をしているにも関わらず、IdP側でその設定をしていないためです。加えて、SAMLリクエストの署名に使用する証明書をIdPに渡していません。
署名と暗号化それぞれで使用する証明書を確認して、ローカルにダウンロードします。
Oktaの設定変更をします。変更前はAssertion Encryption
がUnencrypted
で、SAML Signed Request
がDisabled
であることを確認します。
SAMLアサーションの暗号化とSAMLリクエストの署名の検証を行うように設定します。その際に事前にダウンロードしておいた各証明書を指定します。
それでは、再度https://www.saml-app.non-97.net/
にアクセスします。すると、EC2インスタンス上で動作しているWebサーバーから正常にリクエストが返ってきました。
Cookieを確認すると、ALBのリスナーで指定したセッションCookieが作成されていました。
ユーザープールのユーザー一覧を確認すると、認証に使用したユーザーが登録されていました。
https://www.saml-app.non-97.net/phpinfo.php
にアクセスして、phpinfo()
の結果も確認してみます。
以下記事を参考にSAML認証によってALBで追加されたヘッダーを確認します。
x-amzn-oidc-data
などのヘッダー情報を確認できました。
x-amzn-oidc-data
をデコードしてみましょう。
$ oidc_data='<x-amzn-oidc-data>'
$ echo $oidc_data \
| cut -d'.' -f 2 \
| base64 -D \
| jq
{
"sub": "f4587468-6061-703a-ddd7-2facbd76285f",
"email_verified": "false",
"identities": "[{\"dateCreated\":\"1713847527334\",\"userId\":\"<メールアドレス> \",\"providerName\":\"SamlAppStackPoolProvider96B21C88\",\"providerType\":\"SAML\",\"issuer\":null,\"primary\":\"true\"}]",
"email": "<メールアドレス> ",
"username": "SamlAppStackPoolProvider96B21C88_<メールアドレス> ",
"exp": 1714084995,
"iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_yy2XiquVR"
}
ユーザープールに登録されたユーザー情報とを照らし合わせると、各種値が一致していることが確認できます。
以下記事で紹介しているとおり、アプリケーション側ではこちらの値を元に色々と処理できそうです。
SAML認証を簡単に実装したい場合に
ALBとCognitoを使ってOktaをIdPとするSAML認証をしてみました。
SAML認証を簡単に実装したい場合に役立ちそうですね。
この記事が誰かの助けになれば幸いです。
以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!